Skip to content

feat(sdk-coin-sol): add WASM-based transaction parsing via @bitgo/wasm-solana#7957

Open
lcovar wants to merge 1 commit intomasterfrom
BTC-0-draft-example-sol-decoding
Open

feat(sdk-coin-sol): add WASM-based transaction parsing via @bitgo/wasm-solana#7957
lcovar wants to merge 1 commit intomasterfrom
BTC-0-draft-example-sol-decoding

Conversation

@lcovar
Copy link
Contributor

@lcovar lcovar commented Jan 21, 2026

This PR introduces WASM-based transaction parsing as an alternative to
the legacy @solana/web3.js approach. The WASM implementation provides
cleaner parsing with zero web3.js dependencies for transaction decoding.

Key changes:

  • Add WasmTransaction class (~150 lines vs 800+ in legacy Transaction)
  • Add wasmInstructionMapper to convert WASM parsed instructions to SDK format
  • Add wasmInstructionCombiner to derive transaction types from instructions
  • Update sol.ts to extract transaction IDs using WASM parsing
  • Add comprehensive tests including Jito staking verification

Dependencies:

  • @bitgo/wasm-solana@2.0.0
  • Webpack alias added for browser builds

@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch 27 times, most recently from d32c089 to bb885ad Compare January 28, 2026 18:16
@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch 2 times, most recently from f5732ce to 97c511d Compare January 28, 2026 18:36
@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch 5 times, most recently from c2bcda3 to c03ea63 Compare February 3, 2026 23:10
@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch 4 times, most recently from c0e8e7f to 63284b8 Compare February 9, 2026 22:02
@lcovar lcovar marked this pull request as ready for review February 10, 2026 19:39
@lcovar lcovar requested review from a team as code owners February 10, 2026 19:39
Copy link
Contributor

@OttoAllmendinger OttoAllmendinger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can move towards bigint a little bit more

where external interfaces force us to string keep it

];

const outputs: { address: string; amount: string; memo?: string }[] = [];
let outputAmount = '0';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let outputAmount = '0';
let outputAmount = 0n;

address: instr.params.toAddress,
amount: instr.params.amount,
});
outputAmount = (BigInt(outputAmount) + BigInt(instr.params.amount)).toString();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's avoid the internal bigint casts - why is instr.params.amount not a bigint already?

// Derive outputs and tokenEnablements from combined instructions
const outputs: TransactionRecipient[] = [];
const tokenEnablements: ITokenEnablement[] = [];
let outputAmount = new BigNumber(0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yet another way to sum up things... use bigint instead


/** Transaction ID (first signature, base58 encoded) */
get id(): string {
if (!this._wasmTransaction) return UNAVAILABLE_TEXT;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing the interface forces us to do this ugliness?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately yes, base class forces the return type of string while _id can be string | undefined so it's a bit silly. We have to return a string, i guess we can return an empty string.

Seeing what it would take to update the base class to accept undefined as a return type. It would cause some breaking changes though but lets see. 🤖

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixing the interface could spiral out of control very quickly, so let's leave it. I just need to know what is hard to change and what is easy to change. You can use the override keyword in typescript to signal that a method or getter implements a base method.

@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch 2 times, most recently from 38e4bc7 to da3dc95 Compare February 11, 2026 19:20
@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch 3 times, most recently from 0b3225b to 3cb4054 Compare February 12, 2026 20:22
Copy link
Contributor

@OttoAllmendinger OttoAllmendinger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

counting on you claude

Comment on lines 808 to 860
let outputAmount = 0n;

for (const instr of combinedInstructions) {
switch (instr.type) {
case InstructionBuilderTypes.Transfer:
outputs.push({
address: instr.params.toAddress,
amount: instr.params.amount,
});
outputAmount += BigInt(instr.params.amount);
break;

case InstructionBuilderTypes.TokenTransfer:
outputs.push({
address: instr.params.toAddress,
amount: instr.params.amount,
tokenName: findTokenName(instr.params.tokenAddress ?? '', undefined, true),
});
break;

case InstructionBuilderTypes.CreateNonceAccount:
outputs.push({
address: instr.params.nonceAddress,
amount: instr.params.amount,
});
outputAmount += BigInt(instr.params.amount);
break;

case InstructionBuilderTypes.StakingActivate:
outputs.push({
address: instr.params.stakingAddress,
amount: instr.params.amount,
});
outputAmount += BigInt(instr.params.amount);
break;

case InstructionBuilderTypes.StakingWithdraw:
outputs.push({
address: instr.params.fromAddress,
amount: instr.params.amount,
});
outputAmount += BigInt(instr.params.amount);
break;

case InstructionBuilderTypes.CreateAssociatedTokenAccount:
tokenEnablements.push({
address: instr.params.ataAddress,
tokenName: findTokenName(instr.params.mintAddress, undefined, true),
tokenAddress: instr.params.mintAddress,
});
break;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this whole thing gives me a deja vue

@claude can we consolidate here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude here.

Yes, this output extraction loop is duplicated in three places:

  1. Transaction.explainTransaction() (transaction.ts:525-587) using BigNumber
  2. WasmTransaction.explainTransaction() (wasm/transaction.ts:206-240) using bigint
  3. Sol.explainTransactionWithWasm() (sol.ts:810-860) using bigint

Proposal: extract a shared free function in a new lib/explainUtil.ts:

interface ExplainedOutputs {
  outputs: TransactionRecipient[];
  tokenEnablements: ITokenEnablement[];
  outputAmount: bigint;
  memo?: string;
}

function extractOutputsFromInstructions(instructions: InstructionParams[]): ExplainedOutputs {
  const outputs: TransactionRecipient[] = [];
  const tokenEnablements: ITokenEnablement[] = [];
  let outputAmount = 0n;
  let memo: string | undefined;

  for (const instr of instructions) {
    switch (instr.type) {
      case InstructionBuilderTypes.Transfer:
        outputs.push({ address: instr.params.toAddress, amount: instr.params.amount });
        outputAmount += BigInt(instr.params.amount);
        break;
      case InstructionBuilderTypes.TokenTransfer:
        outputs.push({
          address: instr.params.toAddress,
          amount: instr.params.amount,
          tokenName: instr.params.tokenName,
        });
        break;
      case InstructionBuilderTypes.CreateNonceAccount:
        outputs.push({ address: instr.params.nonceAddress, amount: instr.params.amount });
        outputAmount += BigInt(instr.params.amount);
        break;
      case InstructionBuilderTypes.StakingActivate:
        outputs.push({ address: instr.params.stakingAddress, amount: instr.params.amount });
        outputAmount += BigInt(instr.params.amount);
        break;
      case InstructionBuilderTypes.StakingWithdraw:
        outputs.push({ address: instr.params.fromAddress, amount: instr.params.amount });
        outputAmount += BigInt(instr.params.amount);
        break;
      case InstructionBuilderTypes.CreateAssociatedTokenAccount:
        tokenEnablements.push({
          address: instr.params.ataAddress,
          tokenName: instr.params.tokenName,
          tokenAddress: instr.params.mintAddress,
        });
        break;
      case InstructionBuilderTypes.Memo:
        memo = instr.params.memo;
        break;
    }
  }
  return { outputs, tokenEnablements, outputAmount, memo };
}

All three call sites then become thin wrappers that call this, eliminating the duplication. The legacy Transaction.explainTransaction() can convert the bigint result to BigNumber at the boundary if needed (or migrate to bigint too).

Comment on lines 862 to 864
// Calculate fee: lamportsPerSignature * numSignatures + (rent * numATAs)
const lamportsPerSignature = parseInt(params.feeInfo?.fee || '0', 10);
const rentPerAta = parseInt(params.tokenAccountRentExemptAmount || '0', 10);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude suggest a inner func that has a nicer signature with already parsed args

don't we want bigint for amounts anyway?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude here.

Yes, bigint for amounts. The ExplainTransactionOptions.feeInfo.fee and tokenAccountRentExemptAmount come in as string from the external interface (TransactionFee.fee is string in sdk-core), so we should convert exactly once at the boundary.

Proposal — parse external args at the top, then pass typed values into a pure inner function:

interface WasmExplainArgs {
  combinedInstructions: InstructionParams[];
  transactionType: TransactionType;
  memo?: string;
  nonce: string;
  durableNonce?: DurableNonceParams;
  numSignatures: number;
  signatures: Uint8Array[];
  lamportsPerSignature: bigint;
  rentPerAta: bigint;
}

function buildWasmExplanation(args: WasmExplainArgs): SolLibTransactionExplanation {
  const { outputs, tokenEnablements, outputAmount } = extractOutputsFromInstructions(args.combinedInstructions);
  const signatureFee = args.lamportsPerSignature * BigInt(args.numSignatures);
  const rentFee = args.rentPerAta * BigInt(tokenEnablements.length);
  const totalFee = (signatureFee + rentFee).toString();
  const txId = getTransactionId(args.signatures);

  return {
    displayOrder: [ ... ],
    id: txId,
    type: TransactionType[args.transactionType],
    changeOutputs: [],
    changeAmount: '0',
    outputAmount: outputAmount.toString(),
    outputs,
    fee: { fee: totalFee, feeRate: Number(args.lamportsPerSignature) },
    memo: args.memo,
    blockhash: args.nonce,
    durableNonce: args.durableNonce,
    tokenEnablements,
  };
}

Then explainTransactionWithWasm becomes a thin adapter:

explainTransactionWithWasm(params: ExplainTransactionOptions): SolLibTransactionExplanation {
  const txBytes = Buffer.from(params.txBase64, 'base64');
  const wasmTx = WasmTransaction.fromBytes(txBytes);
  const parsed = wasmParseTransaction(txBytes);
  const { instructions, transactionType } = combineWasmInstructionsFromBytes(txBytes);
  const memo = parsed.instructionsData.find((i) => i.type === 'Memo')?.memo;

  return buildWasmExplanation({
    combinedInstructions: instructions,
    transactionType,
    memo,
    nonce: parsed.nonce,
    durableNonce: parsed.durableNonce,
    numSignatures: parsed.numSignatures,
    signatures: wasmTx.signatures,
    lamportsPerSignature: BigInt(params.feeInfo?.fee || '0'),
    rentPerAta: BigInt(params.tokenAccountRentExemptAmount || '0'),
  });
}

String → bigint conversion happens once at the boundary, all arithmetic is native bigint, and .toString() only at the final return where the external interface requires string.

const totalFee = (signatureFee + rentFee).toString();

// Get transaction id from first signature (base58 encoded) or UNAVAILABLE
let txId = 'UNAVAILABLE';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to be a higher level const

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude here.

Agreed — should use the existing UNAVAILABLE_TEXT constant from lib/constants.ts instead of a string literal. Same constant is already used in WasmTransaction for the same purpose.

Comment on lines 925 to 949
private mapTransactionTypeToString(type: TransactionType): string {
switch (type) {
case TransactionType.Send:
return 'Send';
case TransactionType.WalletInitialization:
return 'WalletInitialization';
case TransactionType.StakingActivate:
return 'StakingActivate';
case TransactionType.StakingDeactivate:
return 'StakingDeactivate';
case TransactionType.StakingWithdraw:
return 'StakingWithdraw';
case TransactionType.StakingDelegate:
return 'StakingDelegate';
case TransactionType.StakingAuthorize:
return 'StakingAuthorize';
case TransactionType.AssociatedTokenAccountInitialization:
return 'AssociatedTokenAccountInitialization';
case TransactionType.CloseAssociatedTokenAccount:
return 'CloseAssociatedTokenAccount';
case TransactionType.CustomTx:
return 'CustomTx';
default:
return 'Send';
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should not be a coin method

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude here.

Agreed. TransactionType is a numeric enum, so TypeScript's reverse mapping already gives us string names. The entire mapTransactionTypeToString method can be replaced with:

TransactionType[transactionType]

This is exactly what the legacy Transaction class does at transaction.ts:645:

type: TransactionType[this.type].toString(),

No switch statement or coin method needed. This should be a standalone expression, not a method on Sol.

…m-solana

- Add WasmTransaction class for WASM-only tx parsing (no @solana/web3.js)
- Add wasmInstructionMapper/Combiner for converting WASM instructions to BitGoJS format
- Add standalone explainSolTransaction() with shared extractOutputsFromInstructions util
- Use bigint for fee calculation, UNAVAILABLE_TEXT constant, override on id getter
- Fix falsy-zero bug in determineTransactionType (TransactionType.Send = 0 is falsy)

BTC-0

TICKET: BTC-0
@lcovar lcovar force-pushed the BTC-0-draft-example-sol-decoding branch from 3cb4054 to 009f956 Compare February 13, 2026 10:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants